t3x: per-thread auto-resume UI (status, on/off, resume message) - #2
Conversation
Phase 1 of docs/superpowers/specs/2026-07-24-t3x-autoresume-ui-design.md. state.ts - ThreadRecord gains `enabled`, plus `setEnabled` / `setOverridePrompt` mutations (overridePrompt was already stored but had no setter). - `enabled` is declared with `Schema.withDecodingDefaultKey(Effect.succeed(true))`, NOT as a required key. The boot path turns a decode failure into EMPTY_STATE, so a required key missing from an older state file would silently destroy every pending resume and the fired history. A regression test pins the legacy on-disk format. Note: the design doc specified `Schema.optionalWith`, which does not exist in this repo's Effect (4.0.0-beta.78). `withDecodingDefaultKey` is the equivalent there. Reactor.ts - Detection: a disabled thread never schedules, and posts no timeline note — the user turned it off deliberately, so an activity row would be noise. - fireOne: re-reads the record instead of trusting the value seen at scheduling time, so a thread switched off *mid-wait* cancels rather than fires, and does not burn one of its 24h attempts. Tests 50 -> 52 green; server typecheck clean. Both new Reactor tests were verified to FAIL when the gates are neutered, so they are real guards rather than vacuous. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 of docs/superpowers/specs/2026-07-24-t3x-autoresume-ui-design.md.
GET /api/t3x/auto-resume?threadId=… -> { enabled, overridePrompt, pending }
POST /api/t3x/auto-resume -> same shape, after applying the write
A raw route, not a WS-RPC method: an RPC would mean editing @t3tools/contracts,
ws.ts and its scope map — several hot upstream files. This costs one additive
line in server.ts's route list, and it is imported from t3x/index.ts alongside
the existing T3xLayerLive, so server.ts gains no new import line.
Auth mirrors the module-private `authenticateRawRouteWithScope` used by the OTLP
proxy route (operate scope, since these endpoints change scheduling behaviour).
Importing it would require exporting it from an upstream file, so the fork
replicates it — recorded as a logic mirror in SEAMS.md.
The layer shape here is load-bearing. Handlers close over the store resolved by
`Layer.unwrap` at layer-construction time rather than `yield*`-ing it from
context. Doing the latter propagated an `AutoResumeStore` requirement out through
HttpRouter into the type of upstream's `makeRoutesLayer`, breaking 222 checks in
server.test.ts and 34 in bin.test.ts. A fork change must never widen an upstream
signature. Server typecheck is back to 0 errors.
Both T3xLayerLive and T3xRoutesLive `Layer.provide` the same AutoResumeStoreLive
value, so Effect's per-build memoisation gives the reactor and the route ONE
store. sharing.test.ts pins that: if it regressed, the route would mutate its own
copy while the reactor read a stale one — the UI toggle would look like it worked
and the thread would resume anyway, silently.
Tests 52 -> 53 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3 of docs/superpowers/specs/2026-07-24-t3x-autoresume-ui-design.md.
A collapsed status pill in the thread view ("Auto-resume: on · next attempt
~3:47 PM") that expands to a switch and a resume-message textbox, backed by
GET/POST /api/t3x/auto-resume.
Web seam is exactly +1 import and +1 JSX element in the server-thread route,
rendered as a sibling of <ChatView> inside <SidebarInset>. Everything else is
new code under apps/web/src/t3x/.
Requests go through `primaryEnvironmentHttpLayer` rather than a hand-rolled
fetch. This is not incidental: the same web bundle runs both in the browser and
inside the Electron renderer (t3code://app), and those authenticate differently
— session cookies with credentials:"include" for a same-origin browser, a
desktop bearer token with credentials:"omit" otherwise. The deciding helper
(`isSameOriginBrowserPrimary`) is module-private, so a hand-rolled fetch would
have to duplicate it; getting it wrong would 401 in the macOS app only, and
because the overlay degrades silently the control would simply never appear
there.
Any failure (401, route absent, offline) resolves to null and renders nothing,
so a broken auto-resume backend can never degrade the chat view.
Positioned top-right: the composer is itself a full-width absolutely-positioned
overlay, so a bottom-right card would sit on top of it. A poll is skipped while
a write is unacked so it cannot stomp an optimistic value, and a thread-id ref
stops a late response from a previous thread being applied to the current one.
Note: ManagedRuntime is created at module level (clientTracing.ts builds its own
inside a function). Deliberate — `make` is lazy, so this shares one HTTP client
across thread views instead of rebuilding per mount.
Web typecheck clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 4 of docs/superpowers/specs/2026-07-24-t3x-autoresume-ui-design.md.
SEAMS.md
- server.ts seam grows from 2 lines to 3 (the route-list entry). Both the layer
and the route still fan in through t3x/index.ts and share one import
statement, so it stays 3 lines however many features are added.
- New logic mirror: autoResume/http.ts replicates the module-private
`authenticateRawRouteWithScope`. If upstream changes how raw routes
authenticate, this route could end up weaker than the ones beside it.
- Web is no longer "none": one mount line in the thread route. Notes that the
overlay reaches the desktop app for free (Electron loads the same web bundle)
and that mobile, being a separate codebase, does not get it.
Contracts and the settings schema remain untouched.
Also marks B7 ("zero UI patch") in the parent spec as superseded for apps/web.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Closes the gap the PR flagged: the route had typecheck and unit coverage but was never exercised over HTTP. Serves ONLY the fork's route layer (not upstream's whole makeRoutesLayer) on an ephemeral port with EnvironmentAuth mocked, and covers: - a rejected credential -> 401/403, not a 500 - a session without the operate scope -> 401/403 - GET on a never-seen thread -> enabled defaults to true - GET without threadId -> 400 - POST patches one field without clobbering the other (a toggle must not wipe a resume message the user is part-way through typing, and vice versa) - POST with a malformed body -> 400, not a 500 Scope limit, stated plainly: auth is mocked, so these do NOT prove the route's `authenticateWithOperateScope` faithfully mirrors upstream's private `authenticateRawRouteWithScope`. That stays a logic mirror tracked in SEAMS.md. They prove the failure paths render as proper status codes rather than escaping as a 500 or an unhandled defect. Writing these caught a mistake in the test itself worth recording: the first version failed a credential with ServerAuthSessionCredentialValidationError, which is in NEITHER `ServerAuthCredentialError` nor `ServerAuthInternalError`, so it matched neither catchIf branch and produced a 500. That looked like a product bug but was a bad mock — upstream's OTLP route uses the identical two branches and behaves the same way. Fixed to use ServerAuthInvalidCredentialError. Tests 53 -> 59 green; server typecheck 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Update: the "no end-to-end verification" caveat is now closedThe description listed this gap:
Tests 53 → 59, typecheck still 0 errors. Scope limit, stated plainlyAuth is mocked, so these do not prove that A mistake worth recordingMy first version of the test failed the credential with Flagging it because it's exactly the kind of thing that becomes a false bug report if you don't chase it down. Still open
|
Adversarial review finding. The severe one is a bug this branch introduced.
state.ts — `recordFor` did `state.threads[threadId] ?? EMPTY_RECORD`. `threads` is a
plain object, so ids like `constructor`, `toString`, `valueOf`, `hasOwnProperty` and
`__proto__` resolve on Object.prototype and are truthy: `??` never falls through and the
caller gets a prototype method typed as a ThreadRecord.
Before this branch threadId only ever arrived from provider events. The new HTTP route is
what makes it caller-controlled, so this branch is what made it reachable:
* reads dereference `record.pending` (undefined, not null) -> TypeError -> the route's
catchTags only cover the three auth tags, so it escapes as a 500;
* writes spread a prototype object, which has no own enumerable properties, and persist
`{"enabled":false}` with none of the required keys. The next boot fails the WHOLE-file
decode, and that path collapses to EMPTY_STATE — silently destroying every pending
resume and the fired history behind the 24h cap.
Now uses `Object.hasOwn`. Regression tests cover all five hostile ids at the store level
plus, at the HTTP boundary, that GET/POST return 200 with defaults instead of 500. All
six were confirmed to FAIL against the old lookup, including "the real thread's pending
resume must survive".
Also from the review:
- AutoResumeOverlay: a debounced resume-message edit was dropped when switching threads
mid-debounce (the next keystroke in the new thread cleared the old timer), silently
losing what the user typed. Pending edits now flush, carrying their originating
threadId. Writes also release the in-flight counter via a guaranteed path — that
counter gates polling, and URL construction can throw synchronously before the promise
exists, which would have pinned it above zero and frozen refreshes for the component's
lifetime.
- Corrected two overstated claims rather than leaving them: t3x/index.ts cited an
`index.test.ts` that does not exist, and sharing.test.ts is now explicit that it guards
the memoisation *assumption* for this composition shape using its own probes — it does
not import T3xLayerLive/T3xRoutesLive and would stay green if they stopped sharing a
store. (The real wiring was independently verified correct from Effect's source: layer
memoisation is keyed on layer identity and provideWith/mergeAllEffect/HttpRouter.serve
all thread one MemoMap.)
Tests 59 -> 66; server and web typecheck both clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts: # docs/t3x/SEAMS.md
Implements
docs/superpowers/specs/2026-07-24-t3x-autoresume-ui-design.md. Gives each thread a visible control for the existing headless auto-resume feature.A collapsed pill in the thread view —
Auto-resume: on · next attempt ~3:47 PM— expands to an on/off switch and a resume-message box.Phases
enabledonThreadRecord,setEnabled/setOverridePrompt, reactor gates both scheduling and firingGET/POST /api/t3x/auto-resumeapps/web/src/t3x/AutoResumeOverlay.tsx+ 1 mount lineUpstream footprint
server.ts: +1 symbol on the existing t3x import, +1 entry in the route list (3 lines total, still fanned in viat3x/index.ts)ChatView.tsx/ChatComposer/ws.tsThree things worth reviewing closely
1. Backward compatibility is a data-loss risk, not a nicety. The store's boot path turns a decode failure into
EMPTY_STATE. So addingenabledas a required key would make every pre-existing state file fail to decode and silently wipe pending resumes + fired history. It's declared withwithDecodingDefaultKey(Effect.succeed(true)), and a test pins the exact legacy on-disk format.2. The route's layer shape prevents a seam leak. Handlers close over a store resolved by
Layer.unwrapat layer-construction time. My first attempt had themyield*it from context — that propagated anAutoResumeStorerequirement out throughHttpRouterinto the type of upstream'smakeRoutesLayerand broke 222 checks inserver.test.ts+ 34 inbin.test.ts. A fork change must never widen an upstream signature. Now 0 errors.3. One store, or the feature silently does nothing. The reactor and the route each
Layer.providethe sameAutoResumeStoreLive; Effect's per-build memoisation makes that one instance. If it ever regressed, the route would mutate its own copy while the reactor read a stale one — the toggle would look like it worked and the thread would resume anyway.sharing.test.tspins it with both reference equality and a cross-visibility write.Why the overlay isn't a plain
fetchThe same web bundle runs in the browser and in the Electron renderer (
t3code://app) — the macOS app has no separate UI. They authenticate differently: session cookie +credentials: "include"for a same-origin browser, bearer token +credentials: "omit"otherwise. The deciding helperisSameOriginBrowserPrimary()is module-private, so a hand-rolled fetch would have to duplicate it — and getting it wrong would 401 in the macOS app only, where the overlay's graceful degradation would make it silently never appear. Going throughprimaryEnvironmentHttpLayergets both branches.Verification
Caveats
Reactor.test.tsearly on and could not reproduce them across 14+ later runs (including under induced CPU load). The harness isTestClock/fiber-timing sensitive. Unresolved — flagging rather than hiding it.docs/t3x/SEAMS.md(both add sections). Trivial to resolve; merge t3x: auto-build & install the desktop app on change #1 first.ManagedRuntimeis created at module level, unlikeclientTracing.tswhich builds one inside a function. Deliberate (lazymake, one shared client), but a reasonable thing to push back on.🤖 Generated with Claude Code